《A Philosophy of Software Design》软件设计哲学(上)

作者:John Ousterhout(斯坦福大学的 Bosack Lerner 计算机科学教授。他是 Tcl 脚本语言的创建者,并且以在分布式操作系统和存储系统中的工作而闻名。Ousterhout 在耶鲁大学获得了物理学学士学位,并在卡内基梅隆大学获得了计算机科学博士学位。他是美国国家工程院院士,并获得了无数奖项,包括 ACM 软件系统奖,ACM Grace Murray Hopper 奖,美国国家科学基金会总统年轻研究者奖和 UC Berkeley 杰出教学奖。)

这本书是关于如何使用复杂性来指导软件设计的整个生命周期。本文为 1 ~ 10 章笔记,后续会调整内容,给予更贴近前端的案例。

todo:案例替换;文案优化。

介绍 Introduction(It’s All About Complexity)

编写软件的最大限制是我们了解所创建系统的能力。随着程序的发展和获得更多功能,它变得复杂,其组件之间具有微妙的依赖性。随着时间的流逝,复杂性不断累积,程序员在修改系统时将所有相关因素牢记在心中变得越来越难。这会减慢开发速度并导致错误,从而进一步延缓开发速度并增加成本。在任何程序的生命周期中,复杂性都会不可避免地增加。程序越大,工作的人越多,管理复杂性就越困难。

好的开发工具可以帮助我们应对复杂性。但是,仅凭工具我们只能做一部分事情。如果我们想简化编写软件的过程,从而可以更简便地构建功能,实现更强大的系统,则必须找到简化软件的方法。尽管我们尽了最大努力,但复杂度仍会随着时间的推移而增加,这时候更简单的设计使我们能够在复杂性压倒性优势之前构建更多、功能更强的系统。

有两种解决复杂性的通用方法,这会在后续说明:

  • 1.通过使代码更简单和更清晰来消除复杂性。例如,可以通过消除特殊情况或以一致的方式使用标识符来降低复杂性。
  • 2.封装它,以便程序员可以在系统上工作而不会立即暴露其所有复杂性。这种方法称为模块化设计。在模块化设计中,软件系统分为模块,例如面向对象语言的类。这些模块被设计为彼此相对独立,以便程序员可以在一个模块上工作而不必了解其他模块的细节。

由于软件具有很好的延展性,因此软件设计是一个贯穿软件系统整个生命周期的连续过程。这使得软件设计与诸如建筑物,船舶或桥梁的物理系统的设计不同。但是,并非总是以这种方式来看待软件设计。在编程的大部分历史中,设计都集中在项目的开始,就像其他工程学科一样。这种方法的极端称为瀑布模型,该模型将项目划分为离散的阶段,例如需求定义,设计,编码,测试和维护。在瀑布模型中,每个阶段都在下一阶段开始之前完成;在许多情况下,每个阶段都由不同的人负责。在设计阶段,立即设计整个系统。

不幸的是,瀑布模型很少适用于软件。软件系统本质上比物理系统复杂。在构建任何东西之前,不可能充分可视化大型软件系统的设计,以了解其所有含义。结果,初始设计将有许多问题。在实施良好之前,问题不会变得明显。但是,瀑布模型的结构此时无法适应主要的设计更改(例如,设计师可能已转移到其他项目)。因此,开发人员尝试在不改变整体设计的情况下解决问题。这导致复杂性的爆炸式增长。

由于这些问题,当今大多数软件开发项目都使用诸如敏捷开发之类的增量方法,其中初始设计着重于整体功能的一小部分。设计、实施和评估此子集。发现和纠正原始设计的问题,然后设计,实施和评估更多功能。每次迭代都会暴露现有设计的问题,这些问题在设计下一组功能之前就已得到解决。通过以这种方式扩展设计,可以在系统仍然很小的情况下解决初始设计的问题。较新的功能受益于较早功能的实施过程中获得的经验,因此问题较少。

增量方法适用于软件,因为软件具有足够的延展性,可以在实施过程中进行重大的设计更改。相比之下,对物理系统而言,主要的设计更改更具挑战性:例如,在建筑过程中更改支撑桥梁的塔架数量不切实际。

增量开发意味着永远不会完成软件设计。设计在系统的整个生命周期中不断发生:开发人员应始终在思考设计问题。增量开发还意味着不断地重新设计。系统或组件的初始设计几乎从来都不是最好的。经验不可避免地显示出更好的做事方式。作为软件开发人员,应该始终在寻找机会来改进正在开发的系统的设计,并且应该计划将部分时间花费在设计改进上。

软件开发人员应始终考虑设计问题,从而降低复杂性是软件设计中最重要的要素,因此软件开发人员应始终考虑复杂性。

复杂性地本质 The Nature of Complexity

识别复杂性的能力是至关重要的设计技能。它使你可以先找出问题,然后再付出大量努力,并可以在其他选择中做出正确的选择。判断一个设计是否简单比创建一个简单的设计要容易得多,但是一旦你认识到一个系统过于复杂,就可以使用该功能指导你的设计哲学走向简单。如果设计看起来很复杂,请尝试其他方法,看看是否更简单。随着时间的流逝,你会注意到某些技术往往会导致设计更简单,而其他技术则与复杂性相关。这将使你更快地制作更简单的设计。

复杂性与软件系统的结构有关,这使它很难理解和修改系统。复杂性可以采取多种形式。例如,可能很难理解一段代码是如何工作的。可能需要花费很多精力才能实现较小的改进,或者可能不清楚必须修改系统的哪些部分才能进行改进;如果不引入其他错误,可能很难修复。如果一个软件系统难以理解和修改,那就很复杂。如果很容易理解和修改,那就很简单。

您还可以考虑成本和收益方面的复杂性。在复杂的系统中,要实施甚至很小的改进都需要大量的工作。在一个简单的系统中,可以用更少的精力实现更大的改进。

复杂性是开发人员在特定时间点在尝试实现特定目标时所经历的。它不一定与系统的整体大小或功能有关。人们通常使用“复杂”一词来描述具有复杂功能的大型系统,但是如果这样的系统易于使用,它并不复杂。当然,实际上几乎所有大型复杂的软件系统都很难使用,因此它们也符合对复杂性的定义,但这不一定是事实。小型而不复杂的系统也可能非常复杂。

复杂性取决于最常见的活动。如果系统中有一些非常复杂的部分,但是几乎不需要触摸这些部分,那么它们对系统的整体复杂性不会有太大影响。公式:
p-1.jpg

即:系统的总体复杂度(C)由每个部分的复杂度(cp)乘以开发人员在该部分上花费的时间(tp)加权。

在一个永远不会被看到的地方隔离复杂性几乎和完全消除复杂性一样好。

读者比作家更容易理解复杂性。如果您编写了一段代码,对您来说似乎很简单,但是其他人则认为它很复杂,那么它就是复杂的。当您遇到这种情况时,有必要对其他开发人员进行调查,以找出为什么代码对他们而言似乎很复杂;从您的观点与观点之间的脱节中可能可以学习一些有趣的课程。作为开发人员,您的工作不仅是创建可以轻松使用的代码,而且还要创建其他人也可以轻松使用的代码。

复杂性通过以下三种段落中描述的三种一般方式体现出来。这些表现形式中的每一个都使执行开发任务变得更加困难。

  • 变更放大:复杂性的第一个征兆是,看似简单的变更需要在许多不同地方进行代码修改。例如,考虑一个包含几个页面的网站,每个页面显示带有背景色的横幅。在许多早期的网站中,颜色是在每个页面上明确指定的。为了更改此类网站的背景,开发人员可能必须手动修改每个现有页面;对于拥有数千个页面的大型网站而言,这几乎是不可能的。幸运的是,现代网站将横幅颜色一次在中心位置指定,并且所有各个页面均引用该共享值。使用这种方法,可以通过一次修改来更改整个网站的标题颜色。
  • 认知负荷:复杂性的第二个症状是认知负荷,这是指开发人员需要多少知识才能完成一项任务。较高的认知负担意味着开发人员必须花更多的时间来学习所需的信息,并且由于错过了重要的东西而导致错误的风险也更大。例如,假设 C 中的一个函数分配了内存,返回了指向该内存的指针,并假定调用者将释放该内存。这增加了使用该功能的开发人员的认知负担。如果开发人员无法释放内存,则会发生内存泄漏。如果可以对系统进行重组,以使调用者不必担心释放内存(分配内存的同一模块也负责释放内存),它将减少认知负担。
  • 未知的未知:复杂性的第三个症状是,必须修改哪些代码才能完成任务,或者开发人员必须获得哪些信息才能成功地执行任务,这些都是不明显的。下图说明了这个问题。网站使用一个中心变量来确定横幅的背景颜色,所以它看起来很容易改变。但是,一些 Web 页面使用较暗的背景色来强调,并且在各个页面中明确指定了较暗的颜色。如果背景颜色改变,那么强调的颜色必须改变以匹配。不幸的是,开发人员不太可能意识到这一点,所以他们可能会更改中央 bannerBg 变量而不更新强调颜色。即使开发人员意识到这个问题,也不清楚哪些页面使用了强调色,因此开发人员可能必须搜索 Web 站点中的每个页面。

p-2.jpg

良好设计的最重要目标之一就是使系统显而易见。这与高认知负荷和未知未知数相反。在一个显而易见的系统中,开发人员可以快速了解现有代码的工作方式以及进行更改所需的内容。一个显而易见的系统是,开发人员可以在不费力地思考的情况下快速猜测要做什么,同时又可以确信该猜测是正确的。

复杂性是由两件事引起的:依赖性和模糊性

当无法孤立地理解和修改给定的一段代码时,便存在依赖关系。该代码以某种方式与其他代码相关,如果更改了给定代码,则必须考虑和/或修改其他代码。在上图的网站示例中,背景色在所有页面之间创建了依赖关系。所有页面都必须具有相同的背景,因此,如果更改一页的背景,则必须更改所有背景。依赖关系的另一个示例发生在网络协议中。通常,协议的发送方和接收方有单独的代码,但是它们必须分别符合协议。更改发送方的代码几乎总是需要在接收方进行相应的更改,反之亦然。

依赖关系是软件的基本组成部分,不能完全消除。实际上,我们在软件设计过程中有意引入了依赖性。每次编写新类时,都会围绕该类的 API 创建依赖关系。但是,软件设计的目标之一是减少依赖关系的数量,并使依赖关系保持尽可能简单和明显。

考虑网站示例。在每个页面分别指定背景的旧网站中,所有网页都是相互依赖的。新的网站通过在中心位置指定背景色并提供一个 API,供各个页面在呈现它们时检索该颜色,从而解决了该问题。新的网站消除了页面之间的依赖关系,但是它围绕 API 创建了一个新的依赖关系以检索背景色。幸运的是,新的依赖性更加明显:很明显,每个单独的网页都取决于 bannerBg 颜色,并且开发人员可以通过搜索其名称轻松找到使用该变量的所有位置。此外,编译器还有助于管理 API 依赖性:如果共享变量的名称发生变化,任何仍使用旧名称的代码都将发生编译错误。新的网站用一种更简单,更明显的方式代替了一种不明显且难以管理的依赖性。

复杂性的第二个原因是晦涩(模糊性)。当重要的信息不明显时,就会发生模糊。一个简单的例子是一个变量名,它是如此的通用,以至于它没有携带太多有用的信息(例如,时间)。或者,一个变量的文档可能没有指定它的单位,所以找到它的惟一方法是扫描代码,查找使用该变量的位置。晦涩常常与依赖项相关联,在这种情况下,依赖项的存在并不明显。例如,如果向系统添加了一个新的错误状态,可能需要向一个包含每个状态的字符串消息的表添加一个条目,但是对于查看状态声明的程序员来说,消息表的存在可能并不明显。不一致性也是造成不透明性的一个主要原因:如果同一个变量名用于两个不同的目的,那么开发人员就无法清楚地知道某个特定变量的目的是什么。

在许多情况下,由于文档不足而导致模糊不清。但是,模糊性也是设计问题。如果系统设计简洁明了,则所需的文档将更少。对大量文档的需求通常是一个警告,即设计不正确。减少模糊性的最佳方法是简化系统设计。

依赖性导致变化放大和高认知负荷。晦涩会产生未知的未知数,还会增加认知负担。如果我们找到最小化依赖关系和模糊性的设计技术,那么我们就可以降低软件的复杂性。

复杂度是递增的(Complexity is incremental )。复杂性不是由单个灾难性错误引起的;它堆积成许多小块。单个依赖项或模糊性本身不太可能显着影响软件系统的可维护性。之所以会出现复杂性,是因为随着时间的流逝,成千上万的小依赖性和模糊性逐渐形成。最终,这些小问题太多了,以至于对系统的每次可能更改都会受到其中几个问题的影响。

复杂性的增量性质使其难以控制。可以很容易地说服自己,当前更改所带来的一点点复杂性没什么大不了的。但是,如果每个开发人员对每种更改都采用这种方法,那么复杂性就会迅速累积。一旦积累了复杂性,就很难消除它,因为修复单个依赖项或模糊性本身不会产生很大的变化。为了减缓复杂性的增长。

复杂性来自于依赖性和模糊性的积累。随着复杂性的增加,它会导致变化放大,高认知负荷和未知的未知数。结果,需要更多的代码修改才能实现每个新功能。此外,开发人员花费更多时间获取足够的信息以安全地进行更改,在最坏的情况下,他们甚至找不到所需的所有信息。最重要的是,复杂性使得修改现有代码库变得困难且冒险。

只写代码是不够的 Working Code Isn’t Enough

好的软件设计中最重要的元素之一是您在执行编程任务时所采用的思维方式。许多组织都鼓励采取战术思维方式,着眼于使功能尽快运行。但是,如果您想要一个好的设计,则必须采取更具战略性的方法,在此上花费时间来制作干净的设计并解决问题。

Tactical programming 战术编程

大多数程序员以我称为战术编程的心态来进行软件开发。在战术方法中,您的主要重点是使某些功能正常工作,例如新功能或错误修复。乍一看,这似乎是完全合理的:还有什么比编写有效的代码更重要的呢?但是,战术编程几乎不可能产生出良好的系统设计。

战术编程的问题是它是短视的。如果您是战术编程人员,那么您将尝试尽快完成任务。也许您有一个艰难的期限。因此,为未来做计划不是优先事项。您不会花费太多时间来寻找最佳设计。您只想尽快使某件事起作用。您告诉自己,可以增加一些复杂性或引入一两个小错误,如果这样可以使当前任务更快地完成,则可以。

这就是系统变得复杂的方式,如之前所说复杂度是递增的。不是使系统复杂的特定事物,而是数十或数百个小事物的积累。如果您进行战术编程,则每个编程任务都会带来一些此类复杂性。为了快速完成当前任务,他们每个人似乎都是一个合理的折衷方案。但是,复杂性迅速累积,尤其是如果每个人都在战术上进行编程的时候。
不久之后,某些复杂性将开始引起问题,并且您将开始希望您没有采用这些早期的捷径。但是,您会告诉自己,使下一个功能正常工作比返回并重构现有代码更为重要。从长远来看,重构可能会有所帮助,但是肯定会减慢当前的任务。因此,您需要快速修补程序来解决遇到的任何问题。这只会增加复杂性,然后需要更多补丁。很快代码变得一团糟,但是到现在为止,情况已经很糟糕了,清理它需要花费数月的时间。您的日程安排无法容忍这种延迟,解决一个或两个问题似乎并没有太大的区别,因此您只是在战术上保持编程。

几乎每个软件开发组织都有至少一个将战术编程发挥到极致的开发人员:战术龙卷风(tactical tornado)。战术龙卷风是一位多产的程序员,他抽出代码的速度比其他人快得多,但完全以战术方式工作。实施快速功能时,没有人能比战术龙卷风更快地完成任务。在某些组织中,管理层将战术龙卷风视为英雄。但是,战术龙卷风留下了毁灭的痕迹。他们很少被将来必须使用其代码的工程师视为英雄。通常,其他工程师必须清理战术龙卷风留下的混乱局面,这使得那些工程师(他们是真正的英雄)的进步似乎比战术龙卷风慢。

Strategic programming 战略规划/战略编程

成为一名优秀的软件设计师的第一步是要意识到仅写代码是不够的。引入不必要的复杂性以更快地完成当前任务是不可接受的。最重要的是系统的长期结构。任何系统中的大多数代码都是通过扩展现有代码库编写的,因此,作为开发人员,最重要的工作就是促进这些将来的扩展。因此,尽管您的代码当然必须工作,但您不应将“工作代码”视为主要目标。您的主要目标必须是制作出出色的设计,并且这种设计也会起作用。这是战略计划。

战略性编程需要一种投资心态。您必须花费时间来改进系统的设计,而不是采取最快的方式来完成当前的项目。这些投资会在短期内让您放慢脚步,但从长远来看会加快您的速度。

一些投资将是积极的。例如,值得花一些时间为每个新类找到一个简单的设计。而不是实施想到的第一个想法,请尝试几种替代设计并选择最简洁的设计。试想一下将来可能需要更改系统的几种方式,并确保设计容易。编写好的文档是主动投资的另一个例子。

其他投资将是被动的。无论您预先投入多少,设计决策中都不可避免地会出现错误。随着时间的流逝,这些错误将变得显而易见。发现设计问题时,不要只是忽略它或对其进行修补。花一些额外的时间来修复它。如果您进行战略性编程,则将不断对系统设计进行小幅改进。这与战术编程相反,在战术编程中,您不断增加一些复杂性,这些复杂性将来会引起问题。

那么,正确的投资额是多少?大量的前期投资(例如尝试设计整个系统)将不会有效。这是瀑布方法,我们知道它不起作用。随着您对系统的了解,理想的设计趋于零碎出现。因此,最好的方法是连续进行大量小额投资 ​​。我建议您将总开发时间的 10%到 20%用于投资。该金额足够小,不会对您的日程安排产生重大影响,但又足够大,可以随着时间的推移产生重大收益。因此,您的初始项目将比纯战术方法花费 10-20%的时间。额外的时间将带来更好的软件设计,并且您将在几个月内开始体验到这些好处。不久之后,您的开发速度将比战术编程快至少 10–20%。在这一点上,您的投资将免费:您过去投资的收益将节省足够的时间来支付未来投资的费用。您将迅速收回初始投资的成本。下图说明了这种现象。

p-3.jpg

图解:一开始,战术性的编程方法将比战略性方法更快地取得进展。但是,在战术方法下,复杂性积累得更快,从而降低了生产率。随着时间的流逝,战略方针会带来更大的进步。注意:此图仅用于定性说明;我不知道对曲线精确形状的任何经验测量。相反,如果您进行战术编程,则可以将第一个项目完成的速度提高 10%到 20%,但是随着时间的推移,复杂性的累积会降低开发速度。不久之后,您的编程速度至少会降低 10–20%。您将很快退回在开始时节省的所有时间,并且在系统的整个生命周期中,与采用策略性方法相比,您的开发速度将更加缓慢。如果您从未使用过严重降级的代码库,请与有经验的人联系。他们会告诉您不良的代码质量会使开发速度至少降低 20%。

创业与投资

在某些环境中,强大的力量与战略方法背道而驰。例如,早期的初创公司感到巨大的压力,需要尽快发布其早期版本。在这些公司中,甚至 10%至 20%的投资似乎也负担不起。结果,许多初创公司采取了战术性的方法,在设计上花费了很少的精力,而在问题出现时则花费了更少的精力进行清理。他们认为,如果成功,他们将有足够的钱聘请额外的工程师来清理问题,从而使其合理化。

如果是一家朝着这个方向发展的公司,则应该意识到,一旦代码库变成了意大利面条,几乎是不可能修复的。您可能会为产品的使用寿命付出高昂的开发成本。此外,好的(或坏的)设计的回报很快就会到来,因此战术方法很有可能甚至不会加快您的首个产品发布的速度。

要考虑的另一件事是,公司成功的最重要因素之一就是工程师的素质。降低开发成本的最佳方法是聘请优秀的工程师:他们的成本不会比普通工程师高很多,但生产率却高得多。但是,最好的工程师对良好的设计深感兴趣。如果您的代码库很残酷,那么单词会变得毫无用处,这将使您难以招募。结果,您最终可能会遇到普通的工程师。这将增加您的未来成本,并可能导致系统结构进一步退化。

Facebook 是一个鼓励战术编程的创业公司的例子。多年来,公司的座右铭是“快速行动并打破困境”。鼓励刚大学毕业的新工程师立即深入公司的代码库;工程师在工作的第一周将承诺投入生产是很正常的。从积极的一面来看,Facebook 作为一家赋予员工权力的公司而享有声誉。工程师拥有极大的自由度,并且几乎没有任何规则和限制。

Facebook 作为一家公司已经取得了令人瞩目的成功,但是由于该公司的战术方法,其代码库受到了影响。许多代码不稳定且难以理解,几乎没有注释或测试,并且使用起来很痛苦。随着时间的流逝,该公司意识到其文化是不可持续的。最终,Facebook 改变了座右铭,即“以坚实的基础架构快速移动”,以鼓励其工程师在良好的设计上进行更多的投资。Facebook 是否能够成功清除多年来战术编程中积累的问题还有待观察。

幸运的是,通过战略方法也有可能在硅谷取得成功。Google 和 VMware 与 Facebook 差不多同时成长,但是这两家公司都采用了更具战略意义的方法。两家公司都非常重视高质量的代码和良好的设计,并且两家公司都开发了复杂的产品,这些产品通过可靠的软件系统解决了复杂的问题。公司的强大技术文化在硅谷广为人知。很少有其他公司可以与他们竞争聘请顶级技术人才。

这些例子表明,一家公司可以成功使用任何一种方法。但是,在一家关心软件设计并拥有清晰代码基础的公司中工作会有趣得多。

好的设计不是免费的。它必须是您不断投资的东西,这样小问题才不会累积成大问题。幸运的是,好的设计最终会收回成本,而且比您想象的要早。

始终如一地运用战略方法并将投资视为今天而不是明天要做的事情至关重要。当您陷入危机时,很容易推迟清理,直到危机结束之后。但是,这是一个湿滑的斜坡。在当前紧缩之后,几乎肯定会再出现一次。一旦开始延迟设计改进,就很容易使延迟永久化,并使您的文化陷入战术方法中。您等待解决设计问题的时间越长,问题就会变得越大;解决方案变得更加令人生畏,这使得轻松推迟解决方案变得更加容易。最有效的方法是,每位工程师都对良好的设计进行连续的少量投资。

模块应该有深度的 Modules Should Be Deep

管理软件复杂性最重要的技术之一就是设计系统,以便开发人员在任何给定时间只需要面对整体复杂性的一小部分。这种方法称为模块化设计。

Modular design 模块化设计

在模块化设计中,软件系统被分解为相对独立的模块集合。模块可以采用多种形式,例如类,子系统或服务。在理想的世界中,每个模块都将完全独立于其他模块:开发人员可以在任何模块中工作,而无需了解任何其他模块。在这个世界上,系统的复杂性就是最糟糕的模块的复杂性。

不幸的是,这种理想是无法实现的。模块必须通过调用彼此的函数或方法来协同工作。结果,模块必须相互了解。模块之间将存在依赖关系:如果一个模块发生更改,则可能需要更改其他模块以进行匹配。例如,方法的参数在方法与调用该方法的任何代码之间创建依赖关系。如果必需的参数更改,则必须修改该方法的所有调用以符合新的签名。依赖关系可以采用许多其他形式,并且它们可能非常微妙。模块化设计的目标是最大程度地减少模块之间的依赖性。

为了管理依赖关系,我们将每个模块分为两个部分:接口实现。接口包含使用其他模块的开发人员必须知道的所有内容,才能使用给定的模块。通常,接口描述模块做什么,而不描述模块如何做。该实现由执行接口所承诺的代码组成。在特定模块中工作的开发人员必须了解该模块的接口和实现,以及由给定模块调用的任何其他模块的接口。除了正在使用的模块以外,开发人员无需了解其他模块的实现。

考虑一个实现平衡树的模块。该模块可能包含复杂的代码,以确保树保持平衡。但是,此复杂性对于模块用户而言是不可见的。用户可以看到一个相对简单的接口,用于调用在树中插入,删除和获取节点的操作。要调用插入操作,调用者只需提供新节点的键和值即可。遍历树和拆分节点的机制在接口中不可见。

模块是具有接口和实现的任何代码单元。面向对象编程语言中的每个类都是一个模块。类中的方法或非面向对象语言中的函数也可以视为模块:每个模块都有一个接口和一个实现,并且可以将模块化设计技术应用于它们。更高级别的子系统和服务也是模块。它们的接口可能采用不同的形式,例如内核调用或 HTTP 请求。这本书中有关模块化设计的许多讨论都集中在设计类上,但是技术和概念也适用于其他种类的模块。

最好的模块是那些其接口比其实现简单得多的模块。这样的模块具有两个优点。首先,一个简单的接口可以将模块强加于系统其余部分的复杂性降至最低。其次,如果以不更改其接口的方式修改了一个模块,则该修改不会影响其他模块。如果模块的接口比其实现简单得多,则可以在不影响其他模块的情况下更改模块的许多方面。

接口中有什么?

模块的接口包含两种信息:正式信息和非正式信息(formal and informal)。接口的形式部分在代码中明确指定,并且其中一些可以通过编程语言检查其正确性。例如,方法的形式接口是其签名,其中包括其参数的名称和类型,其返回值的类型以及有关该方法引发的异常的信息。大多数编程语言都确保对方法的每次调用都提供正确数量和类型的参数以匹配其签名。类的形式接口包括其所有公共方法的签名以及任何公共变量的名称和类型。

每个接口还包括非正式元素。这些没有以编程语言可以理解或执行的方式指定。接口的非正式部分包括其高级行为,例如,函数删除由其参数之一命名的文件的事实。如果对类的使用存在限制(也许必须先调用一种方法),则这些约束也是类接口的一部分。通常,如果开发人员需要了解特定信息才能使用模块,则该信息是模块接口的一部分。接口的非正式方面只能使用注释来描述,而编程语言不能确保描述是完整或准确的。对于大多数接口,非正式方面比正式方面更大,更复杂。

明确指定接口的好处之一是,它可以准确指示开发人员使用关联模块所需要知道的内容。这有助于消除之前描述的“未知的未知”问题。

Abstractions 抽象

术语抽象与模块化设计的思想紧密相关。抽象是实体的简化视图,其中省略了不重要的细节。抽象是有用的,因为它们使我们更容易思考和操纵复杂的事物。

在模块化编程中,每个模块以其接口的形式提供抽象。该接口提供了模块功能的简化视图;从模块抽象的角度来看,实现的细节并不重要,因此在接口中将其省略。

在抽象的定义中,“无关紧要”(unimportant)一词至关重要。从抽象中忽略的不重要的细节越多越好。但是,如果细节不重要,则只能将其从抽象中省略。抽象可以通过两种方式出错。首先,它可以包含并非真正重要的细节。当这种情况发生时,它会使抽象变得不必要的复杂,从而增加了使用抽象的开发人员的认知负担。第二个错误是抽象忽略了真正重要的细节。这导致模糊不清:仅查看抽象的开发人员将不会获得正确使用抽象所需的全部信息。忽略重要细节的抽象是错误的抽象:它可能看起来很简单,但实际上并非如此。

例如,考虑一个文件系统。文件系统提供的抽象省略了许多细节,例如用于选择存储设备上的哪些块用于给定文件中的数据的机制。这些详细信息对于文件系统的用户而言并不重要(只要系统提供足够的性能即可)。但是,文件系统实现的一些细节对用户很重要。大多数文件系统将数据缓存在主内存中,并且它们可能会延迟将新数据写入存储设备以提高性能。一些应用程序(例如数据库)需要确切地知道何时将数据写入存储设备,因此它们可以确保在系统崩溃后将保留数据。因此,将数据刷新到辅助存储的规则必须在文件系统的接口中可见。

我们不仅依靠抽象来管理复杂性,而且不仅在编程中,而且在日常生活中无处不在。微波炉包含复杂的电子设备,可将交流电转换为微波辐射并将该辐射分布到整个烹饪腔中。幸运的是,用户看到了一个简单得多的抽象,它由几个按钮控制微波的定时和强度。汽车提供了一种简单的抽象概念,使我们可以在不了解电动机,电池电源管理,防抱死制动,巡航控制等机制的情况下驾驶它们。

Deep modules 深度模块

最好的模块是那些提供强大功能但具有简单接口的模块。用“深入”一词来描述这样的模块。为了形象化深度的概念,假设每个模块都由一个矩形表示,如下图所示。每个矩形的面积与模块实现的功能成比例。矩形的顶部边缘代表模块的接口;边缘的长度表示接口的复杂性。最好的模块很深:它们在简单的接口后隐藏了许多功能。深度模块是一个很好的抽象,因为其内部复杂性的很小一部分对其用户可见。

p-4.jpg

图解:深浅模块。最好的模块很深:它们允许通过简单的接口访问许多功能。浅层模块是具有相对复杂的接口的模块,但功能不多:它不会掩盖太多的复杂性。

模块深度是考虑成本与收益的一种方式。模块提供的好处是其功能。模块的成本(就系统复杂性而言)是其接口。模块的接口代表了模块强加给系统其余部分的复杂性:接口越小越简单,引入的复杂性就越小。最好的模块是那些收益最大,成本最低的模块。接口不错,但更多或更大的接口不一定更好!

Unix 操作系统及其后代(例如 Linux)提供的文件 I/O 机制是深层接口的一个很好的例子。I/O 只有五个基本系统调用,带有简单签名:

1
2
3
4
5
int open(const char* path, int flags, mode_t permissions);
ssize_t read(int fd, void* buffer, size_t count);
ssize_t write(int fd, const void* buffer, size_t count);
off_t lseek(int fd, off_t offset, int referencePosition);
int close(int fd);

开放系统调用采用分层文件名,例如/a/b/c,并返回一个整数文件描述符,该描述符用于引用打开文件。open 的其他自变量提供可选信息,例如是否正在打开文件以进行读取或写入,如果不存在现有文件则是否应创建新文件,以及如果创建新文件则具有文件的访问权限。读写系统调用在应用程序内存和文件的缓冲区之间传输信息。close 结束对文件的访问。大多数文件是按顺序访问的,因此这是默认设置。但是,可以通过调用 lseek 系统调用来更改当前访问位置来实现随机访问。

  • Unix I/O 接口的现代实现需要成千上万行代码,这些代码可以解决诸如以下的复杂问题:
  • 如何在磁盘上表示文件以便有效访问?
  • 如何存储目录,以及如何处理分层路径名以查找它们所引用的文件?
  • 如何强制执行权限,以使一个用户无法修改或删除另一用户的文件?
  • 如何实现文件访问?例如,如何在中断处理程序和后台代码之间划分功能,以及这两个元素如何安全通信?
  • 在同时访问多个文件时使用什么调度策略?
  • 如何将最近访问的文件数据缓存在内存中以减少磁盘访问次数?
  • 如何将各种不同的辅助存储设备(例如磁盘和闪存驱动器)合并到单个文件系统中?

所有这些问题,以及更多的问题,都由 Unix 文件系统实现来解决。对于调用系统调用的程序员来说,它们是不可见的。多年来,Unix I/O 接口的实现已经发生了根本的发展,但是五个基本内核调用并没有改变。

深度模块的另一个示例是诸如 Go 或 Java 之类的语言中的垃圾收集器。这个模块根本没有接口。它在后台进行隐形操作以回收未使用的内存。由于将垃圾收集消除了用于释放对象的接口,因此向系统中添加垃圾回收实际上会缩小其总体接口。垃圾收集器的实现非常复杂,但是使用该语言的程序员无法发现这种复杂性。

诸如 Unix I/O 和垃圾收集器之类的深层模块提供了强大的抽象,因为它们易于使用,但隐藏了巨大的实现复杂性。

Shallow modules 浅模块

另一方面,浅层模块是其接口与其提供的功能相比相对复杂的模块。例如,实现链表的类很浅。操作链表不需要太多代码(插入或删除元素仅需几行),因此链表抽象不会隐藏很多细节。链表接口的复杂度几乎与其实现的复杂度一样高。浅类有时是不可避免的,但是它们在管理复杂性方面没有提供太多帮助。

这是一个浅层方法的极端示例,该浅层方法来自软件设计类的项目:

1
2
3
private void addNullValueForAttribute(String attribute) {
data.put(attribute, null);
}

从管理复杂性的角度来看,此方法会使情况变得更糟,而不是更好。该方法不提供任何抽象,因为其所有功能都可以通过其接口看到。例如,调用者可能需要知道该属性将存储在 data 变量中。考虑接口并不比考虑完整实现简单。如果正确记录了该方法,则文档将比该方法的代码长。与调用方直接操作数据变量相比,调用该方法所花费的击键甚至更多。该方法增加了复杂性(以供开发人员学习的新接口的形式),但没有提供任何补偿。

浅层模块是一个接口相对于其提供的功能而言复杂的模块。浅层模块在对抗复杂性方面无济于事,因为它们提供的好处(不必了解它们在内部如何工作)被学习和使用其接口的成本所抵消。小模块往往很浅。

Classitis(?)

不幸的是,深度类的价值在今天并未得到广泛认可。编程中的传统观点是,类应该小而不是深。经常告诉学生,类设计中最重要的事情是将较大的类分成较小的类。对于方法,通常会给出相同的建议:“任何长于 N 行的方法都应分为多种方法”(N 可以低至 10)。这种方法导致了大量的浅类和方法,这增加了整体系统的复杂性。

“类应该小”的极端做法是作者称之为“类炎”的综合症,这是由于错误地认为“类是好的,所以类越多越好”。在遭受类炎的系统中,鼓励开发人员最小化每个新类的功能:如果您想要更多的功能,请引入更多的类。分类炎可能导致个别地简单的分类,但是却增加了整个系统的复杂性。小类不会贡献太多功能,因此必须有很多小类,每个小类都有自己的接口。这些接口的累积会在系统级别产生巨大的复杂性。小类也导致冗长的编程风格,这是由于每个类都需要样板。

通过将模块的接口与其实现分开,我们可以将实现的复杂性从系统的其余部分中隐藏出来。模块的用户只需要了解其接口提供的抽象。设计类和其他模块时,最重要的问题是使它们更深,以使它们具有适用于常见用例的简单接口,但仍提供重要的功能。这使隐藏的复杂性最大化。

信息隐藏(和泄漏)Information Hiding (and Leakage)

Information hiding 信息隐藏

实现深层模块最重要的技术是信息隐藏。该技术最早由 David Parnas 描述。基本思想是每个模块应封装一些知识,这些知识代表设计决策。该知识嵌入在模块的实现中,但不会出现在其界面中,因此其他模块不可见。

隐藏在模块中的信息通常包含有关如何实现某种机制的详细信息。以下是一些可能隐藏在模块中的信息示例:

  • 如何在 B 树中存储信息,以及如何有效地访问它。
  • 如何识别与文件中每个逻辑块相对应的物理磁盘块。
  • 如何实现 TCP 网络协议。
  • 如何在多核处理器上调度线程。
  • 如何解析 JSON 文档。

隐藏的信息包括与该机制有关的数据结构和算法。它还可以包含较低级别的详细信息(例如页面大小),还可以包含更抽象的较高级别的概念,例如大多数文件较小的假设。

信息隐藏在两个方面降低了复杂性。首先,它将接口简化为模块。接口反映了模块功能的更简单、更抽象的视图,并隐藏了细节;这减少了使用该模块的开发人员的认知负担。例如,使用 B-tree 类的开发人员不需要考虑树中节点的理想扇出,也不需要考虑如何保持树的平衡。其次,信息隐藏使系统更容易演化。如果隐藏了一段信息,那么在包含该信息的模块之外就不存在对该信息的依赖,因此与该信息相关的设计更改将只影响一个模块。例如,如果 TCP 协议发生了变化(例如,为了引入一种新的拥塞控制机制),协议的实现就必须进行修改,但是在使用 TCP 发送和接收数据的高级代码中不需要进行任何修改。

设计新模块时,应仔细考虑可以在该模块中隐藏哪些信息。如果您可以隐藏更多信息,则还应该能够简化模块的界面,这会使模块更深。

注意:通过声明变量和方法为私有来隐藏类中的变量和方法与信息隐藏不是同一回事。私有元素可以帮助隐藏信息,因为它们使无法从类外部直接访问项目。但是,有关私人物品的信息仍可以通过公共方法(如 getter 和 setter 方法)公开。发生这种情况时,变量的性质和用法就如同变量是公开的一样暴露。

信息隐藏的最佳形式是将信息完全隐藏在模块中,从而使该信息对模块的用户无关且不可见。但是,部分信息隐藏也具有价值。例如,如果某个类的某些用户仅需要特定的功能或信息,并且可以通过单独的方法对其进行访问,以使其在最常见的用例中不可见,则该信息通常会被隐藏。与类的每个用户可见的信息相比,此类信息将创建更少的依赖项。

Information leakage 信息泄漏

信息隐藏的反面是信息泄漏。当一个设计决策反映在多个模块中时,就会发生信息泄漏。这在模块之间创建了依赖关系:对该设计决策的任何更改都将要求对所有涉及的模块进行更改。如果一条信息反映在模块的界面中,则根据定义,该信息已经泄漏;因此,更简单的界面往往与更好的信息隐藏相关。但是,即使信息未出现在模块的界面中,也可能会泄漏信息。假设两个类都具有特定文件格式的知识(也许一个类读取该格式的文件,而另一个类写入它们)。即使两个类都不在其接口中公开该信息,它们都取决于文件格式:如果格式更改,则两个类都将需要修改。

信息泄漏是软件设计中最重要的危险信号之一。作为一个软件设计师,你能学到的最好的技能之一就是对信息泄露的高度敏感性。如果您在类之间遇到信息泄漏,请自问“我如何才能重新组织这些类,使这些特定的知识只影响一个类?”如果受影响的类相对较小,并且与泄漏的信息紧密相关,那么将它们合并到一个类中是有意义的。另一种可能的方法是从所有受影响的类中提取信息,并创建一个只封装这些信息的新类。但是,这种方法只有在您能够找到一个从细节中抽象出来的简单接口时才有效;如果新类通过其接口公开了大部分知识,那么它就不会提供太多的价值(您只是用通过接口的泄漏替换了后门泄漏)。

当在多个地方使用相同的知识时,例如两个都理解特定类型文件格式的不同类,就会发生信息泄漏。

Temporal decomposition 时间分解

信息泄漏的一个常见原因是我称为时间分解的设计风格。在时间分解中,系统的结构对应于操作将发生的时间顺序。考虑一个应用程序,该应用程序以特定格式读取文件,修改文件内容,然后再次将文件写出。通过时间分解,该应用程序可以分为三类:一类用于读取文件,另一类用于执行修改,第三类用于写出新版本。文件读取和文件写入步骤都具有有关文件格式的知识,这会导致信息泄漏。解决方案是将用于读写文件的核心机制结合到一个类中。该类将在应用程序的读取和写入阶段使用。很容易陷入时间分解的陷阱,因为在编写代码时通常会想到必须执行操作的顺序。但是,大多数设计决策会在应用程序的整个生命周期中的多个不同时刻表现出来。结果,时间分解常常导致信息泄漏。

顺序通常很重要,因此它将反映在应用程序中的某个位置。但是,除非该结构与信息隐藏保持一致(也许不同阶段使用完全不同的信息),否则不应将其反映在模块结构中。在设计模块时,应专注于执行每个任务所需的知识,而不是任务发生的顺序。

在时间分解中,执行顺序反映在代码结构中:在不同时间发生的操作在不同的方法或类中。如果在执行的不同点使用相同的知识,则会在多个位置对其进行编码,从而导致信息泄漏。

信息隐藏和深层模块密切相关。如果模块隐藏了很多信息,则往往会增加模块提供的功能,同时还会减少其接口。这使模块更深。相反,如果一个模块没有隐藏太多信息,则它要么功能不多,要么接口复杂。无论哪种方式,模块都是浅的。
将系统分解为模块时,请尽量不要受运行时操作顺序的影响。这将使您沿着时间分解的路径前进,这将导致信息泄漏和模块浅。相反,请考虑执行应用程序任务所需的不同知识,并设计每个模块以封装这些知识中的一个或几个。这将产生带有深色模块的干净简单的设计。

通用模块更深入 General-Purpose Modules are Deeper

设计新模块时,面临的最普遍的决定之一就是是以通用还是专用方式实现它。有人可能会争辩说,您应该采用通用方法,在这种方法中,您将实现一种可用于解决广泛问题的机制,而不仅是当今重要的问题。在这种情况下,新机制可能会在将来发现意外用途,从而节省时间。通用方法似乎与之前讨论的投资思路一致,在这里您花了更多时间在前面,以节省以后的时间。

另一方面,我们知道很难预测软件系统的未来需求,因此通用解决方案可能包含从未真正需要的功能。此外,如果您实现的东西过于通用,那么可能无法很好地解决您今天遇到的特定问题。结果,有些人可能会争辩说,最好只关注当今的需求,构建您所知道的需求,并针对您今天打算使用的方式进行专门化处理。如果您采用特殊用途的方法并在以后发现更多用途,则始终可以对其进行重构以使其通用。专用方法似乎与软件开发的增量方法一致。

Make classes somewhat general-purpose 使类变得通用

有效的方法是以某种通用的方式实现新模块。短语“有点通用”(somewhat general-purpose)表示该模块的功能应反映您当前的需求,但其接口则不应。相反,该接口应该足够通用以支持多种用途。该界面应易于使用,以满足当今的需求,而不必专门与它们联系在一起。“有点”这个词很重要:不要被带走并建造通用的东西,以致于很难满足当前的需求。
通用方法最重要的(也许是令人惊讶的)好处是,与专用方法相比,它导致更简单,更深入的界面。如果您将该类用于其他目的,则通用方法还可以节省将来的时间。但是,即使该模块仅用于其原始用途,由于其简单性,通用方法仍然更好。

A more general-purpose API 更通用的 API

更好的方法是使文本类更通用。仅应根据基本文本功能定义其 API,而不应反映将用其实现的更高级别的操作。

Generality leads to better information hiding 通用性可以更好地隐藏信息

通用方法在文本和用户界面类之间提供了更清晰的分隔,从而可以更好地隐藏信息。文本类不需要知道用户界面的详细信息,例如如何处理退格键。这些细节现在封装在用户界面类中。可以添加新的用户界面功能,而无需在文本类中创建新的支持功能。通用界面还减轻了认知负担:使用用户界面的开发人员只需要学习一些简单的方法,就可以将其重复用于各种目的。

文本类原始版本中的 backspace 方法是错误的抽象。它旨在隐藏有关删除哪些字符的信息,但是用户界面模块确实需要知道这一点。用户界面开发人员可能会阅读退格方法的代码,以确认其精确的行为。将方法放在文本类中只会使用户界面开发人员更难获得所需的信息。软件设计最重要的元素之一就是确定谁需要知道什么以及何时知道。当细节很重要时,最好使它们明确且尽可能明显,例如修订的 Backspace 操作实现。将这些信息隐藏在界面后面只会产生晦涩感。

Questions to ask yourself 问自己的问题

识别干净的通用类设计要比创建一个简单。您可以问自己一些问题,这将帮助您在接口的通用和专用之间找到适当的平衡。

满足我当前所有需求的最简单的界面是什么?如果减少 API 中的方法数量而不降低其整体功能,则可能正在创建更多通用的方法。专用文本 API 至少具有三种删除文本的方法:退格,删除和 deleteSelection。通用性更强的 API 只有一种删除文本的方法,可同时满足所有三个目的。仅在每种方法的 API 保持简单的前提下,减少方法的数量才有意义。如果您必须引入许多其他参数以减少方法数量,那么您可能并没有真正简化事情。

在多少情况下会使用此方法?如果一种方法是为特定用途而设计的,例如退格方法,那是一个危险信号,它可能太特殊了。看看是否可以用一个通用方法替换几种专用方法。

这个 API 是否易于使用以满足我当前的需求?这个问题可以帮助您确定何时使 API 变得简单而通用。如果您必须编写许多其他代码才能将类用于当前用途,那么这是一个危险信号,即该接口未提供正确的功能。例如,针对文本类的一种方法是围绕单字符操作进行设计:insert 插入单个字符,而 delete 删除单个字符。该 API 既简单又通用。但是,对于文本编辑器来说并不是特别容易使用:更高级别的代码将包含许多循环,用于插入或删除字符范围。单字符方法对于大型操作也将是低效的。

通用接口比专用接口具有许多优点。它们往往更简单,使用的方法更少。它们还提供了类之间的更清晰的分隔,而专用接口则倾向于在类之间泄漏信息。使模块具有某种通用性是降低整体系统复杂性的最佳方法之一。

不同的层,不同的抽象(Different Layer, Different Abstraction)

软件系统由层组成,其中较高的层使用较低层提供的功能。在设计良好的系统中,每一层都提供与其上,下两层不同的抽象。如果您通过调用方法遵循单个操作在层中上下移动,则每个方法调用的抽象都会改变。例如:

  • 在文件系统中,最上层实现文件抽象。文件由可变长度的字节数组组成,可以通过读写可变长度的字节范围来更新该字节。文件系统的下一个下一层在固定大小的磁盘块的内存中实现了高速缓存。调用者可以假定经常使用的块将保留在内存中,以便可以快速访问它们。最低层由设备驱动程序组成,它们在辅助存储设备和内存之间移动块。
  • 在诸如 TCP 的网络传输协议中,最顶层提供的抽象是从一台机器可靠地传递到另一台机器的字节流。此级别在较低级别上构建,该级别可以尽最大努力在计算机之间传输有限大小的数据包:大多数数据包将成功交付,但某些数据包可能会丢失或乱序交付。

如果系统包含具有相似抽象的相邻层,则这是一个红色标记,表明类分解存在问题。

Pass-through methods 直通方法

当相邻的层具有相似的抽象时,问题通常以直通方法的形式表现出来。直通方法是一种很少执行的方法,除了调用另一个方法(其签名与调用方法的签名相似或相同)之外。

直通方法是一种不执行任何操作的方法,只是将其参数传递给另一个方法,通常使用与直通方法相同的 API。这通常表示各类之间没有明确的职责划分。

直通方法使类变浅:它们增加了类的接口复杂性,从而增加了复杂性,但是并没有增加系统的整体功能。

直通方法表明类之间的责任划分存在混淆。

解决方案是重构类,以使每个类都有各自不同且连贯的职责

p-5.jpg

图解:直通方法。在(a)中,类 C1 包含三个直通方法,这些方法只调用 C2 中具有相同签名的方法(每个符号代表一个特定的方法签名)。可以通过使 C1 的调用方像在(b)中那样直接调用 C2,通过在 C1 和 C2 之间重新分配功能以避免在(c)中的类之间进行调用,或者通过组合在(d)中的类来消除直通方法。 。

When is interface duplication OK? 接口复制何时可以?

具有相同签名的方法并不总是不好的。重要的是,每种新方法都应贡献重要的功能。直通方法很糟糕,因为它们不提供任何新功能。

分派器是一个示例,该示例对于一种方法调用具有相同签名的另一种方法很有用。调度程序是一种使用其参数选择要调用的其他方法之一的方法。然后将其大部分或所有参数传递给所选方法。调度程序的签名通常与其调用的方法的签名相同。即便如此,调度程序仍提供有用的功能:它可以选择其他几种方法中的哪一种来执行每个任务。

例如,当 Web 服务器从 Web 浏览器接收到传入的 HTTP 请求时,它将调用一个调度程序,该调度程序检查传入请求中的 URL 并选择一种特定的方法来处理该请求。某些 URL 可以通过返回磁盘上文件的内容来处理。其他人则可以通过调用诸如 PHP 或 JavaScript 之类的语言的过程来处理。分发过程可能非常复杂,通常由与传入 URL 匹配的一组规则来驱动。

只要每种方法都提供有用且独特的功能,几种方法都应具有相同的签名。调度程序调用的方法具有此属性。另一个示例是具有多种实现方式的接口,例如操作系统中的磁盘驱动程序。每个驱动程序都支持不同类型的磁盘,但是它们都有相同的接口。当几种方法提供同一接口的不同实现时,它将减少认知负担。使用其中一种方法后,与其他方法一起使用会更容易,因为您无需学习新的接口。像这样的方法通常位于同一层,并且它们不会相互调用。

Decorators 装饰器

装饰器设计模式(也称为“包装器”)是一种鼓励跨层复制 API 的模式。装饰对象接受现有对象并扩展其功能;它提供一个与底层对象相似或相同的 API,它的方法调用底层对象的方法。在第 4 章的 Java I/O 示例中,BufferedInputStream 类是一个装饰器:给定一个 InputStream 对象,它提供了相同的 API,但是引入了缓冲。例如,当它的 read 方法被调用来读取单个字符时,它会调用底层 InputStream 上的 read 来读取更大的块,并保存额外的字符来满足未来的 read 调用。另一个例子出现在窗口系统中:Window 类实现了一个不能滚动的窗口的简单形式,而 ScrollableWindow 类通过添加水平和垂直滚动条来装饰窗口类。

装饰器的动机是将类的专用扩展与更通用的核心分开。但是,装饰器类往往很浅:它们引入了大量的样板,以实现少量的新功能。装饰器类通常包含许多直通方法。过度使用装饰器模式很容易,为每个小的新功能创建一个新类。这导致诸如 Java I/O 示例之类的浅层类激增。

创建装饰器类之前,请考虑以下替代方法:

  • 您能否将新功能直接添加到基础类,而不是创建装饰器类?如果新功能是相对通用的,或者在逻辑上与基础类相关,或者如果基础类的大多数使用也将使用新功能,则这是有意义的。例如,几乎每个创建 Java InputStream 的人都会创建一个 BufferedInputStream,并且缓冲是 I/O 的自然组成部分,因此应该合并这些类。
  • 如果新功能专用于特定用例,将其与用例合并而不是创建单独的类是否有意义?
  • 您可以将新功能与现有的装饰器合并,而不是创建新的装饰器吗?这将导致一个更深的装饰器类,而不是多个浅的装饰器类。
  • 最后,问问自己新功能是否真的需要包装现有功能:是否可以将其实现为独立于基类的独立类?在窗口示例中,滚动条可能与主窗口分开实现,而无需包装其所有现有功能。

有时装饰者很有意义,但通常有更好的选择。Sometimes decorators make sense, but there is usually a better alternative.

Interface versus implementation 接口与实现

“不同层,不同抽象”规则的另一个应用是,类的接口通常应与其实现不同:内部使用的表示形式应与接口中出现的抽象形式不同。如果两者具有相似的抽象,则该类可能不是很深。例如,在第 6 章讨论的文本编辑器项目中,大多数团队都以文本行的形式实现了文本模块,每行分别存储。一些团队还使用 getLine 和 putLine 之类的方法围绕行设计了文本类的 API。但是,这使文本类使用起来较浅且笨拙。在较高级别的用户界面代码中,通常在行中间插入文本(例如,当用户键入内容时)或删除跨行的文本范围。通过用于文本类的面向行的 API,调用者被迫拆分和合并行以实现用户界面操作。这段代码很简单,并且在用户界面的实现中被复制和散布。

文本类提供面向字符的接口时,使用起来要容易得多,例如,insert 方法可在文本的任意位置插入任意文本字符串(可能包括换行符),而 delete 方法则删除文本在文本中的两个任意位置之间。在内部,文本仍以行表示。面向字符的接口封装了文本类内部的行拆分和连接的复杂性,这使文本类更深,并简化了使用该类的高级代码。通过这种方法,文本 API 与面向行的存储机制大不相同。差异表示该类提供的有价值的功能。

Pass-through variables 传递变量

跨层 API 复制的另一种形式是传递变量,该变量是通过一长串方法向下传递的变量。下图显示了数据中心服务的示例。命令行参数描述用于安全通信的证书。只有底层方法 m3 才需要此信息,该方法调用一个库方法来打开套接字,但是该信息会通过 main 和 m3 之间路径上的所有方法向下传递。cert 变量出现在每个中间方法的签名中。

传递变量增加了复杂性,因为它们强制所有中间方法知道它们的存在,即使这些方法对变量没有用处。此外,如果存在一个新变量(例如,最初构建的系统不支持证书,但是您后来决定添加该支持),则可能必须修改大量的接口和方法才能将变量传递给所有相关路径。

消除传递变量可能具有挑战性。一种方法是查看最顶层和最底层方法之间是否已共享对象。在下图的数据中心服务示例中,也许存在一个对象,其中包含有关网络通信的其他信息,这对于 main 和 m3 都是可用的。如果是这样,main 可以将证书信息存储在该对象中,因此不必通过通往 m3 的路径上的所有干预方法来传递证书。但是,如果存在这样的对象,则它本身可能是传递变量(m3 还将如何访问它?)。

另一种方法是将信息存储在全局变量中,如下图所示。这避免了将信息从一个方法传递到另一个方法的需要,但是全局变量几乎总是会产生其他问题。例如,全局变量使得不可能在同一过程中创建同一系统的两个独立实例,因为对全局变量的访问会发生冲突。在生产中似乎不太可能需要多个实例,但是它们通常在测试中很有用。

常使用的解决方案是引入一个上下文对象,如下图所示。上下文存储应用程序的所有全局状态(否则将是传递变量或全局变量的任何状态)。大多数应用程序在其全局状态下具有多个变量,这些变量表示诸如配置选项,共享子系统和性能计数器之类的内容。每个系统实例只有一个上下文对象。上下文允许系统的多个实例在单个进程中共存,每个实例都有自己的上下文。

不幸的是,在许多地方可能都需要上下文,因此它有可能成为传递变量。为了减少必须意识到的方法数量,可以将上下文的引用保存在系统的大多数主要对象中。在下图的示例中,包含 m3 的类将对上下文的引用作为实例变量存储在其对象中。创建新对象时,创建方法将从其对象中检索上下文引用,并将其传递给新对象的构造函数。使用这种方法,上下文随处可见,但在构造函数中仅作为显式参数出现。

p-6.jpg

图解:处理传递变量的可能技术。在(a)中,证书通过方法 m1 和 m2 传递,即使它们不使用它也是如此。在(b)中,main 和 m3 具有对一个对象的共享访问权,因此可以将变量存储在此处,而不用将其传递给 m1 和 m2。在(c)中,cert 存储为全局变量。在(d)中,证书与其他系统范围的信息(例如超时值和性能计数器)一起存储在上下文对象中;对上下文的引用存储在其方法需要访问它的所有对象中。

上下文对象统一了所有系统全局信息的处理,并且不需要传递变量。如果需要添加新变量,则可以将其添加到上下文对象;除了上下文的构造函数和析构函数外,现有代码均不受影响。由于上下文全部存储在一个位置,因此上下文可以轻松识别和管理系统的全局状态。上下文也便于测试:测试代码可以通过修改上下文中的字段来更改应用程序的全局配置。如果系统使用传递变量,则实施此类更改将更加困难。

上下文远非理想的解决方案。存储在上下文中的变量具有全局变量的大多数缺点。例如,为什么存在特定变量或在何处使用特定变量可能并不明显。没有纪律,上下文会变成巨大的数据抓包,从而在整个系统中创建不明显的依赖关系。上下文也可能产生线程安全问题;避免问题的最佳方法是使上下文中的变量不可变。不幸的是,我没有找到比上下文更好的解决方案。

接口,参数,函数,类或定义之类的添加到系统中的每个设计基础架构都会增加复杂性,因为开发人员必须了解该元素。为了使元素能够提供相对于复杂性的净收益,它必须消除在没有设计元素的情况下会出现的一些复杂性。否则,最好不要使用该特定元素来实施系统。例如,一个类可以通过封装功能来降低复杂性,以使该类的用户无需意识到这一点。

“不同的层,不同的抽象”规则只是此思想的一种应用:如果不同的层具有相同的抽象,例如直通方法或装饰器,则很有可能它们没有提供足够的利益来补偿它们代表的其他基础结构。类似地,传递参数要求几种方法中的每一种都知道它们的存在(这增加了复杂性),而又不提供其他功能

降低复杂性 Pull Complexity Downwards

假设您正在开发一个新模块,并且发现了一个不可避免的复杂性。哪个更好:应该让模块用户处理复杂性,还是应该在模块内部处理复杂性?如果复杂度与模块提供的功能有关,则第二个答案通常是正确的答案。大多数模块拥有的用户多于开发人员,因此开发人员遭受的苦难要大于用户。作为模块开发人员,您应该努力使模块用户的生活尽可能轻松,即使这对您来说意味着额外的工作。表达此想法的另一种方法是,模块具有简单的接口比简单的实现更为重要。

作为开发人员,很容易以相反的方式行事:解决简单的问题,然后将困难的问题推给其他人。如果出现不确定如何处理的条件,最简单的方法是引发异常并让调用方处理它。如果不确定要实施什么策略,则可以定义一些配置参数来控制该策略,然后由系统管理员自行确定最佳策略。

这样的方法短期内会使您的生活更轻松,但它们会加剧复杂性,因此许多人必须处理一个问题,而不仅仅是一个人。例如,如果一个类抛出异常,则该类的每个调用者都必须处理该异常。如果一个类导出配置参数,则每个安装中的每个系统管理员都必须学习如何设置它们。

降低复杂性时要谨慎处理;这个想法很容易被夸大。一种极端的方法是将整个应用程序的所有功能归为一个类,这显然没有意义。如果(a)被降低的复杂度与该类的现有功能密切相关,(b)降低复杂度将导致应用程序中其他地方的许多简化,则降低复杂度最有意义。简化了类的界面。请记住,目标是最大程度地降低整体系统复杂性。

在开发模块时,请寻找机会减轻自己的痛苦,以减轻用户的痛苦。When developing a module, look for opportunities to take a little bit of extra suffering upon yourself in order to reduce the suffering of your users.

在一起更好还是分开更好?Better Together Or Better Apart?

软件设计中最基本的问题之一是:给定两个功能,它们应该在同一位置一起实现,还是应该分开实现?这个问题适用于系统中的所有级别,例如功能,方法,类和服务。例如,应该在提供面向流的文件 I/O 的类中包括缓冲,还是应该在单独的类中?HTTP 请求的解析应该完全在一种方法中实现,还是应该在多个方法(甚至多个类)之间划分?本章讨论做出这些决定时要考虑的因素。这些因素中的一些已经在前面的章节中进行了讨论,但是为了完整起见,这里将对其进行重新讨论。

在决定是合并还是分开时,目标是降低整个系统的复杂性并改善其模块化。看来实现此目标的最佳方法是将系统划分为大量的小组件:组件越小,每个单独的组件可能越简单。但是,细分的行为会带来额外的复杂性,而这在细分之前是不存在的:

  • 一些组件的复杂性仅来自组件的数量:组件越多,就越难以追踪所有组件,也就越难在大型集合中找到所需的组件。细分通常会导致更多接口,并且每个新接口都会增加复杂性。
  • 细分可能会导致附加代码来管理组件。例如,在细分之前使用单个对象的一段代码现在可能必须管理多个对象。
  • 细分产生分离:细分后的组件将比细分前的组件相距更远。例如,在细分之前位于单个类中的方法可能在细分之后位于不同的类中,并且可能在不同的文件中。分离使开发人员更难于同时查看这些组件,甚至很难知道它们的存在。如果组件真正独立,那么分离是好的:它使开发人员可以一次- 专注于单个组件,而不会被其他组件分散注意力。另一方面,如果组件之间存在依赖性,则分离是不好的:开发人员最终将在组件之间来回翻转。更糟糕的是,他们可能不了解依赖关系,这可能导致错误。
  • 细分可能导致重复:细分之前的单个实例中存在的代码可能需要存在于每个细分的组件中。

如果它们紧密相关,则将代码段组合在一起是最有益的。如果各部分无关,则最好分开。以下是两个代码相关的一些提示:

  • 他们共享信息;例如,这两段代码都可能取决于特定类型文档的语法。
  • 它们一起使用:任何使用其中一段代码的人都可能同时使用另一段代码。这种关系形式只有在双向关系中才具有吸引力。作为反例,磁盘块高速缓存几乎总是包含哈希表,但是哈希表可以在许多不涉及块高速缓存的情况下使用。因此,这些模块应该分开。
  • 它们在概念上重叠,因为存在一个简单的更高级别的类别,其中包括这两段代码。例如,搜索子字符串和大小写转换都属于字符串操作类别。流控制和可靠的交付都属于网络通信的范畴。
  • 不看其中的一段代码就很难理解。

Bring together to eliminate duplication 消除重复

如果发现反复重复相同的代码模式,请查看是否可以重新组织代码以消除重复。一种方法是将重复的代码分解为一个单独的方法,并用对该方法的调用替换重复的代码段。如果重复的代码段很长并且替换方法具有简单的签名,则此方法最有效。如果代码段只有一两行,那么用方法调用替换它可能不会有太多好处。如果代码段与其环境以复杂的方式进行交互(例如,通过访问多个局部变量),则替换方法可能需要复杂的签名(例如,许多“按引用传递”参数),这会降低其价值。
消除重复的另一种方法是重构代码,使相关代码段仅需要在一个地方执行。

Separate general-purpose and special-purpose code 单独的通用代码和专用代码

如果模块包含可用于多种不同目的的机制,则它应仅提供一种通用机制。它不应包含专门针对特定用途的机制的代码,也不应包含其他通用机制。与通用机制关联的专用代码通常应放在不同的模块中(通常是与特定用途关联的模块)。

如果相同的代码(或几乎相同的代码)一遍又一遍地出现,那是一个危险信号,您没有找到正确的抽象。

通常,系统的下层倾向于更通用,而上层则更专用。例如,应用程序的最顶层包含完全特定于该应用程序的功能。将专用代码与通用代码分开的方法是将专用代码向上拉到较高的层,而将较低的层保留为通用。当您遇到同时包含通用功能和专用功能的同一类的类时,请查看该类是否可以分为两个类,一个包含通用功能,另一个在其上分层以提供特殊功能

Splitting and joining methods 拆分和合并方法

何时细分的问题不仅适用于类,而且还适用于方法:是否有时最好将现有方法分为多个较小的方法?还是应该将两种较小的方法合并为一种较大的方法?长方法比短方法更难于理解,因此许多人认为仅长度是分解方法的一个很好的理由。课堂上的学生通常会获得严格的标准,例如“拆分超过 20 行的任何方法!”

但是,长度本身很少是拆分方法的一个很好的理由。通常,开发人员倾向于过多地分解方法。拆分方法会引入其他接口,从而增加了复杂性。它还将原始方法的各个部分分开,如果这些部分实际上是相关的,则使代码更难阅读。您不应该分解一种方法,除非它使整个系统更加简单;我将在下面讨论这种情况。

长方法并不总是坏的。例如,假设一个方法包含按顺序执行的五个 20 行代码块。如果这些块是相对独立的,则可以一次读取并理解该方法的一个块。将每个块移动到单独的方法中并没有太大的好处。如果这些块具有复杂的交互作用,则将它们保持在一起就显得尤为重要,这样读者就可以一次看到所有代码。如果每个块使用单独的方法,则读者将不得不在这些扩展方法之间来回切换,以了解它们如何协同工作。如果方法具有简单的签名并且易于阅读,则包含数百行代码的方法就可以了。这些方法很深入(很多功能,简单的接口),很好。

p-7.jpg

图解:方法(a)可以通过提取子任务(b)或将其功能划分为两个单独的方法(c)进行拆分。如果方法导致浅层方法,则不应拆分该方法,如(d)所示。

设计方法时,最重要的目标是提供简洁的抽象。每种方法都应该做一件事并且完全做到这一点。该方法应该具有简洁的接口,以便用户无需费神就可以正确使用它。该方法应该很深:其接口应该比其实现简单得多。如果一个方法具有所有这些属性,那么它的长短与否可能无关紧要。总体而言,拆分方法只有在其导致更抽象的抽象时才有意义。

应该有可能独立地理解每种方法。如果您不能不理解另一种方法的实现而无法理解一种方法的实现,那就是一个危险信号。该危险信号也可以在其他情况下发生:如果两段代码在物理上是分开的,但是只有通过查看另一段代码才能理解它们,这就是危险信号。

拆分或加入模块的决定应基于复杂性。选择一种结构,它可以隐藏最佳的信息,最少的依赖关系和最深的接口。

定义不存在的错误 Define Errors Out Of Existence

异常处理是软件系统中最糟糕的复杂性来源之一。处理特殊情况的代码在本质上比处理正常情况的代码更难编写,并且开发人员经常在定义异常时不考虑异常的处理方式。本章讨论了为什么异常对复杂性的贡献不成比例,然后说明了如何简化异常处理。本章总的主要教训是减少必须处理异常的地方的数量。在许多情况下,可以修改操作的语义,以便正常行为可以处理所有情况,并且没有要报告的特殊条件。

Why exceptions add complexity 为什么异常会增加复杂性

许多编程语言都包含一种正式的异常机制,该机制允许异常由低级代码引发并由封闭代码捕获。但是,即使不使用正式的异常报告机制,异常也可能发生,例如,当某个方法返回一个特殊值指示其未完成其正常行为时。所有这些形式的异常都会增加复杂性。

一段特定的代码可能会以几种不同的方式遇到异常:

  • 调用方可能会提供错误的参数或配置信息。
  • 调用的方法可能无法完成请求的操作。例如,I/O 操作可能失败,或者所需的资源可能不可用。
  • 在分布式系统中,网络数据包可能会丢失或延迟,服务器可能无法及时响应,或者对等方可能会以意想不到的方式进行通信。
  • 该代码可能会检测到错误,内部不一致或未准备处理的情况。

大型系统必须应对许多特殊情况,特别是在它们是分布式的或需要容错的情况下。异常处理可以占系统中所有代码的很大一部分。

异常处理代码天生就比正常情况下的代码更难写。异常中断了正常的代码流;它通常意味着某事没有像预期的那样工作。当异常发生时,程序员可以用两种方法处理它,每种方法都很复杂。第一种方法是向前推进并完成正在进行的工作,尽管存在例外。例如,如果一个网络数据包丢失,它可以被重发;如果数据损坏了,也许可以从冗余副本中恢复数据。第二种方法是中止正在进行的操作,向上报告异常。但是,中止可能很复杂,因为异常可能发生在系统状态不一致的地方(数据结构可能已经部分初始化);异常处理代码必须恢复一致性,例如通过撤销发生异常之前所做的任何更改。

此外,异常处理代码为更多异常创造了机会。考虑重新发送丢失的网络数据包的情况。也许该数据包实际上并没有丢失,但是只是被延迟了。在这种情况下,重新发送数据包将导致重复的数据包到达对等方;这引入了对等方必须处理的新的例外条件。或者,考虑从冗余副本恢复丢失的数据的情况:如果冗余副本也丢失了怎么办?在恢复期间发生的次要异常通常比主要异常更加微妙和复杂。如果通过中止正在进行的操作来处理异常,则必须将此异常作为另一个异常报告给调用方。为了防止无休止的异常级联,开发人员最终必须找到一种在不引入更多异常的情况下处理异常的方法。

语言对异常的支持往往是冗长而笨拙的,这使得异常处理代码难以阅读。例如,考虑以下代码,该代码使用 Java 对对象序列化和反序列化的支持从文件中读取 tweet 的集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
try (
FileInputStream fileStream = new FileInputStream(fileName);
BufferedInputStream bufferedStream = new BufferedInputStream(fileStream);
ObjectInputStream objectStream = new ObjectInputStream(bufferedStream);
) {
for (int i = 0; i < tweetsPerFile; i++) {
tweets.add((Tweet) objectStream.readObject());
}
}
catch (FileNotFoundException e) {
...
}
catch (ClassNotFoundException e) {
...
}
catch (EOFException e) {
// Not a problem: not all tweet files have full
// set of tweets.
}
catch (IOException e) {
...
}
catch (ClassCastException e) {
...
}

只是基本的 try-catch 样板代码比正常情况下的操作代码所占的代码行更多,甚至没有考虑实际处理异常的代码。很难将异常处理代码与普通情况代码相关联:例如,每个异常的生成位置都不明显。另一种方法是将代码分解为许多不同的 try 块。在极端情况下,可能会尝试尝试每行可能产生异常的代码。这样可以清楚地说明异常发生的位置,但是 try 块本身会破坏代码流,并使代码难以阅读。此外,某些异常处理代码可能最终会在多个 try 块中重复。

确保异常处理代码真正起作用是困难的。某些异常(例如 I/O 错误)在测试环境中不易生成,因此很难测试处理它们的代码。异常在运行的系统中很少发生,因此异常处理代码很少执行。错误可能会长时间未被发现,并且当最终需要异常处理代码时,它很有可能无法正常工作(我最喜欢的一句话是:“未执行的代码无效”) 。最近的一项研究发现,分布式数据密集型系统中超过 90%的灾难性故障是由错误的错误处理引起的 1。当异常处理代码失败时,很难调试该问题,因为它很少发生。

Too many exceptions 异常过多

程序员通过定义不必要的异常加剧了与异常处理有关的问题。告诉大多数程序员,检测和报告错误很重要。他们通常将其解释为“检测到的错误越多越好”。这导致了一种过分防御的风格,其中任何看起来甚至有点可疑的东西都被拒绝,并带有异常,这导致了不必要的异常的泛滥,从而增加了系统的复杂性。

试图使用异常来避免处理困难的情况很诱人:与其想出一种干净的方法来处理它,不如抛出一个异常并将问题平移给调用者。有人可能会争辩说,这种方法可以赋予调用者权力,因为它允许每个调用者以不同的方式处理异常。但是,如果您在确定特定情况下该怎么做时遇到困难,则呼叫者很可能都不知道该怎么办。在这种情况下生成异常只会将问题传递给其他人,并增加系统的复杂性。

类抛出的异常是其接口的一部分;具有大量异常的类具有复杂的接口,并且比具有较少异常的类浅。异常是接口中特别复杂的元素。它可以在被捕获之前通过多个堆栈级别向上传播,因此它不仅影响方法的调用者,而且还可能影响更高级别的调用者(及其接口)。

抛出异常很容易;处理它们很困难。因此,异常的复杂性来自异常处理代码。减少由异常处理引起的复杂性破坏的最佳方法是减少必须处理异常的位置的数量。本章的其余部分将讨论减少异常处理程序数量的四种技术。

Define errors out of existence 定义错误不存在

消除异常处理复杂性的最好方法是定义您的 API,以便没有异常要处理:定义错误而已。这似乎是牺牲品,但在实践中非常有效。

Mask exceptions 掩码异常

减少必须处理异常的地方数量的第二种技术是异常屏蔽。使用这种方法,可以在系统的较低级别上检测和处理异常情况,因此,更高级别的软件无需知道该情况。异常屏蔽在分布式系统中尤其常见。例如,在诸如 TCP 的网络传输协议中,由于各种原因(例如损坏和拥塞),可能会丢弃数据包。TCP 通过在其实现中重新发送丢失的数据包来掩盖数据包的丢失,因此所有数据最终都将通过,并且客户端不知道丢失的数据包。

异常屏蔽并非在所有情况下都有效,但是在它起作用的情况下它是一个强大的工具。它导致了更深的类,因为它减少了类的界面(用户需要注意的异常更少)并以掩盖异常的代码形式添加了功能。异常屏蔽是降低复杂性的一个例子。

Exception aggregation 异常聚集

减少与异常相关的复杂性的第三种技术是异常聚合。异常聚合的思想是用一个代码段处理许多异常。与其为多个单独的异常编写不同的处理程序,不如用一个处理程序将它们全部处理在一个地方。

考虑如何处理 Web 服务器中缺少的参数。Web 服务器实现 URL 的集合。服务器收到传入的 URL 时,将分派到特定于 URL 的服务方法来处理该 URL 并生成响应。该 URL 包含用于生成响应的各种参数。每个服务方法都将调用一个较低层的方法(将其称为 getParameter)以从 URL 中提取所需的参数。如果 URL 不包含所需的参数,则 getParameter 会引发异常。

当参加软件设计课程的学生实现这样的服务器时,他们中的许多人将对 getParameter 的每个不同调用包装在单独的异常处理程序中以捕获 NoSuchParameter 异常,如下图所示。这导致大量的处理程序,所有这些处理程序基本上都执行相同的操作(生成错误响应)。

p-8.jpg

图解:顶部的代码将分派给 Web 服务器中的几种方法之一,每种方法都处理一个特定的 URL。每个方法(底部)都使用传入 HTTP 请求中的参数。在此图中,每个对 getParameter 的调用都有一个单独的异常处理程序。这导致重复的代码。

更好的方法是汇总异常。让它们传播到 Web 服务器的顶级调度方法,而不是在单个服务方法中捕获异常,如下图所示。此方法中的单个处理程序可以捕获所有异常,并为丢失的参数生成适当的错误响应。

在 Web 示例中甚至可以采用聚合方法。处理网页时,除了缺少参数外,还有许多其他错误;例如,参数可能没有正确的语法(服务方法应为整数,但值为“ xyz”),或者用户可能无权执行所请求的操作。在每种情况下,错误都应导致错误响应。错误仅在响应中包含的错误消息中有所不同(“ URL 中不存在参数’数量’”或“’数量’参数的错误值’xyz’;必须为正整数”)。因此,所有导致错误响应的条件都可以使用单个顶级异常处理程序进行处理。错误消息可以在引发异常时生成,并作为变量包含在异常记录中。例如,getParameter 将生成“ URL 中不存在的参数’数量’”消息。顶级处理程序从异常中提取消息,并将其合并到错误响应中。

p-9.jpg

图解:此代码在功能上等效于前一张图,但是异常处理已聚合:分派器中的单个异常处理程序从所有特定于 URL 的方法中捕获所有 NoSuchParameter 异常。

从封装和信息隐藏的角度来看,上一段中描述的聚合具有良好的属性。顶级异常处理程序封装了有关如何生成错误响应的知识,但对特定错误一无所知。它仅使用异常中提供的错误消息。getParameter 方法封装了有关如何从 URL 提取参数的知识,并且还知道如何以人类可读的形式描述提取错误。这两个信息密切相关,因此将它们放在同一位置是很有意义的。但是,getParameter 对 HTTP 错误响应的语法一无所知。随着向 Web 服务器中添加了新功能,可能会创建具有类似自身错误的新方法,如 getParameter。

此示例说明了用于异常处理的通用设计模式。如果系统处理一系列请求,则定义一个异常以中止当前请求,清除系统状态并继续下一个请求非常有用。异常被捕获在系统请求处理循环顶部附近的单个位置。在处理中止请求的任何时候都可以抛出该异常。可以为不同的条件定义异常的不同子类。应该将这种类型的异常与对整个系统致命的异常区分开来。

如果异常在处理之前在堆栈中传播了多个级别,则异常聚集最有效。这样可以在同一位置处理更多方法的更多异常。这与异常屏蔽相反:如果使用低级方法处理异常,则屏蔽通常效果最好。对于屏蔽,低级方法通常是许多其他方法使用的库方法,因此,允许传播异常会增加处理该异常的位置数。掩码和聚合的相似之处在于,这两种方法都将异常处理程序置于可以捕获最多异常的位置,从而消除了许多本来需要创建的处理程序。

考虑异常聚合的一种方法是,它用可以处理多种情况的单个通用机制替换了几种针对特定情况而量身定制的特殊用途的机制。这再次说明了通用机制的好处。

Just crash? 崩溃了吗?

减少与异常处理相关的复杂性的第四种技术是使应用程序崩溃。在大多数应用程序中,有些错误是不值得尝试的。通常,这些错误很难或不可能处理,而且很少发生。针对这些错误的最简单的操作是打印诊断信息,然后中止应用程序。

一个示例是在存储分配期间发生的“内存不足”错误。考虑一下 C 语言中的 malloc 函数,如果它无法分配所需的内存块,则该函数将返回 NULL。这是一个不幸的行为,因为它假定 malloc 的每个调用者都将检查返回值并在没有内存的情况下采取适当的措施。应用程序包含许多对 malloc 的调用,因此在每次调用后检查结果将增加相当大的复杂性。如果程序员忘记了检查(这很有可能),那么如果内存用完,应用程序将取消引用空指针,从而导致崩溃,从而掩盖了实际问题。

此外,当应用程序发现内存已用完时,它无能为力。原则上,应用程序可以寻找不需要的内存以释放它,但是如果应用程序有不需要的内存,它可以已经释放它,这首先可以防止内存不足错误。当今的系统具有如此大的内存,以至于内存几乎永远不会耗尽。如果是这样,通常表明应用程序中存在错误。因此,尝试处理内存不足错误几乎没有道理。这会带来太多的复杂性,而带来的收益却太少。

更好的方法是定义一个新的 ckalloc 方法,该方法调用 malloc,检查结果,并在内存耗尽时通过错误消息中止应用程序。该应用程序从不直接调用 malloc。它总是调用 ckalloc。

在较新的语言(例如 C ++和 Java)中,如果内存耗尽,则 new 运算符将引发异常。捕获此异常没有什么意义,因为异常处理程序很有可能还会尝试分配内存,这也会失败。动态分配的内存是任何现代应用程序中的基本元素,如果内存耗尽,则继续应用程序是没有意义的。最好在检测到错误后立即崩溃。

还有许多其他错误示例,这些错误会使应用程序崩溃很有意义。对于大多数程序,如果在读取或写入打开的文件时发生 I/O 错误(例如磁盘硬错误),或者无法打开网络套接字,则应用程序无济于事,因此中止了操作。清除错误消息是一种明智的方法。这些错误很少发生,因此它们不太可能影响应用程序的整体可用性。如果应用程序遇到内部错误(如数据结构不一致),则错误消息中止也是合适的。这样的条件可能表明程序中存在错误。

在特定错误上崩溃是否可以接受取决于应用程序。对于复制的存储系统,不适合因 I/O 错误而中止。相反,系统必须使用复制的数据来恢复丢失的任何信息。恢复机制将给程序增加相当大的复杂性,但是恢复丢失的数据是系统为用户提供的价值的重要组成部分。

Design special cases out of existence 设计特殊情况不存在

出于同样的原因,定义不存在的错误是有意义的,而定义其他不存在的特殊情况也是有意义的。特殊情况可能导致代码中混入 if 语句,这使代码难以理解并导致错误。因此,应尽可能消除特殊情况。做到这一点的最佳方法是设计一种普通情况,这种方式可以自动处理特殊情况而无需任何额外的代码。

通过消除“不选择”的特殊情况,可以简化选择处理代码,从而使选择始终存在。当屏幕上没有可见的选择时,可以在内部用空的选择表示,其开始和结束位置相同。使用这种方法,可以编写选择管理代码,而无需对“不选择”进行任何检查。复制所选内容时,如果所选内容为空,则将在新位置插入 0 字节(如果正确实现,则在特殊情况下无需检查 0 字节)。同样,应该有可能设计用于删除选择的代码,以便无需任何特殊情况检查就可以处理空情况。在一行上考虑所有选择。要删除选择,提取选择之前的行的一部分,并将其与选择之后的行的部分连接起来以形成新行。如果选择为空,则此方法将重新生成原始行。

Taking it too far 走得太远

定义异常或将其屏蔽在模块内部,仅在模块外部不需要异常信息时才有意义。对于本章中的示例,例如 Tcl unset 命令和 Java 子字符串方法,都是如此。在极少数情况下,呼叫者关心异常检测到的特殊情况,还有其他方法可以获取此信息。

但是,有可能使这个想法太过分。在用于网络通信的模块中,一个学生团队掩盖了所有网络异常:如果发生网络错误,则模块将其捕获,丢弃并继续进行,就好像没有问题一样。这意味着使用该模块的应用程序无法确定消息是否丢失或对等服务器是否发生故障;没有这些信息,就不可能构建健壮的应用程序。在这种情况下,模块必须公开异常,即使它们增加了模块接口的复杂性。

与软件设计中的许多其他领域一样,您必须确定哪些是重要的,哪些是不重要的。不重要的事物应该被隐藏起来,它们越多越好。但是,当某件事很重要时,必须将其暴露出来。

任何形式的特殊情况都使代码更难以理解,并增加了发生错误的可能性。本章重点讨论异常,异常是特殊情况代码的最重要来源之一,并讨论了如何减少必须处理异常的地方的数量。做到这一点的最佳方法是重新定义语义以消除错误条件。对于无法定义的异常,您应该寻找机会将它们掩盖到较低的水平,以免影响有限,或者将多个特殊情况的处理程序聚合到一个更通用的处理程序中。总之,这些技术可能会对整体系统复杂性产生重大影响。